Skip to content

feat: OData v4 bound action support (validator + 'bcli action' verb + batch fix)#21

Merged
igor-ctrl merged 6 commits into
mainfrom
feat/odata-bound-actions
May 22, 2026
Merged

feat: OData v4 bound action support (validator + 'bcli action' verb + batch fix)#21
igor-ctrl merged 6 commits into
mainfrom
feat/odata-bound-actions

Conversation

@igor-ctrl
Copy link
Copy Markdown
Owner

Summary

Adds first-class support for OData v4 bound actions, plus an incidental batch-runner bug fix surfaced during the work.

Before this change, any URL of the form <entitySet>(<key>)/<Namespace>.<action> was rejected at the registry-validation layer before reaching the wire. There was no way to invoke [ServiceEnabled] procedures on PageType = API pages through bcli.

What's included

  1. Registry validator recognises OData v4 bound-action URLs.
    New regex <entitySet>(<key>)/<Identifier>(\.<Identifier>)+ is detected in _resolve_url_for_target; only the parent entity set is looked up against the registry. The disable_standard_api security gate continues to apply on the parent, so locked-down profiles still get their protection. Namespace is agnostic — Microsoft.NAV.archive and Custom.Ns.doStuff both work.

  2. New bcli action verb.

    bcli action <entitySet> <key> <actionName>
                [--data JSON | @file] [--no-data]
                [--namespace Microsoft.NAV]
                [--publisher/--group/--version]
                [--idempotency-key] [--yes] [--format ...]
    

    Composes the URL internally so shells don't have to escape the parens/dot. POSTs the body. Returns ✓ Action invoked (204 No Content) on a void action, or pretty-prints the JSON body otherwise. Empty body is the default — matches bcli post semantics. --no-data is kept as an explicit alias.

  3. Unbound actions at service root.
    Namespace.action URLs with no parent entity now route to companies(<cid>)/<Namespace>.<action> under either the standard /api/v2.0/ path or an explicit --publisher/--group/--version custom route. disable_standard_api still blocks them on locked-down profiles unless a custom route is supplied — same security posture as standard entity sets.

  4. batch run POST→GET silent-downgrade fix.
    Workflow YAML steps with method: were being silently treated as GET because the schema uses action:. Aliased method:action: (lowercased) in the four dict-read call sites + the pydantic model. Mutually-exclusive: setting both method: and action: on a single step now errors.

Domain-neutral

All test fixtures use generic names (examples, widgets, items, doSomething, archive, cancel). Default namespace is Microsoft.NAV for ergonomic BC usage but the validator and verb are namespace-agnostic.

Tests

862 passed, 5 skipped (was 831 passed on main; +31 new tests across:

  • tests/test_url/test_bound_action_resolve.py (bound + unbound routing, custom-route override, lockdown enforcement)
  • tests/test_cli/test_action_cmd.py (data/no-data variants, namespace override, profile overrides)
  • tests/test_workflow/test_method_alias.py (POST→GET regression, model validation, dict-read sites).

Known follow-ups (not in scope)

  • registry import --from-metadata does not yet parse <Action> / <ActionImport> elements from $metadata. Discoverable actions via bcli endpoint info <name> will require a registry schema extension.
  • WorkflowDef/StepDef pydantic models exist but the workflow loader bypasses model_validate; the runtime alias logic in batch_cmd is the load-bearing path. Worth wiring model_validate into the loader so unknown step keys surface as YAML errors instead of being silently ignored.

igor-ctrl added 6 commits May 22, 2026 15:12
Adds `_parse_bound_action` + `_is_unbound_action` helpers and wires them
into `_resolve_url_for_target` so a URL of the form
`<entitySet>(<key>)/<Namespace>.<...>.<Identifier>` is recognised as a
bound-action invocation. The registry validator now looks up only the
parent entity set and splices the action tail back onto the resolved
URL, instead of treating the whole literal string as a missing entity
set.

The pattern is namespace-agnostic; tests exercise both `Microsoft.NAV.*`
and a generic `Custom.Ns.*` namespace. `disable_standard_api` still
applies — gated on the parent entity, which is the right grain (a bound
action's policy follows the entity it's bound to).

Unbound actions at the service root (`/<Namespace>.<action>`) are
explicitly rejected with a clear "not yet supported" error. Pass-through
would require bypassing the company-id URL builder, which is a much
bigger change than the bound-action case the bug report calls out.
A YAML step that wrote 'method: POST' (instead of 'action: post') was
silently downgraded to a GET — the runner reads step.get("action") with
a default of "get", and 'method' was an undocumented unknown key. The
bug surfaces when users copy-paste OData examples, especially bound-
action invocations, where the conventional HTTP method key is 'method'.

Fix in three places that all read the action key off the raw dict:

  * StepDef gains a model_validator that translates 'method:' to
    'action:' (lowercased) before field validation, and rejects the
    combination of both keys to keep YAML one-way.
  * _execute_batch and the dry-run renderer apply the same alias logic
    when reading the raw step dict.
  * The disable_writes pre-flight that classifies mutating steps uses
    a shared _step_action helper so a 'method: POST' step still trips
    the write gate.

Regression test asserts a YAML step with 'method: POST' reaches
client.post — the AsyncMock now raises on .get to lock the contract.
A new top-level verb that lets a user invoke an OData v4 bound action
without hand-writing the parens-and-dot-escaped URL:

    bcli action examples 42 archive --no-data
    bcli action items "'ALFKI'" doSomething --data '{"flag": true}'
    bcli action widgets 7 cancel --namespace Custom.Ns --data @body.json

Internally the verb composes the synthetic
'<entitySet>(<key>)/<Namespace>.<action>' string and forwards it to the
same client.post path that 'bcli post' uses — so the registry
validator's new bound-action recognition (previous commit) covers it
for free.

Design choices:

  * --data and --no-data are mutually exclusive AND one is required.
    Defaulting silently to '{}' would mask a forgotten parameter on a
    real action; making the user choose forces an audit trail.
  * Default namespace is 'Microsoft.NAV' (BC convention) with --namespace
    for override. Tests exercise a 'Custom.Ns' namespace to lock in
    namespace-agnosticism.
  * Actions are always POST per the OData spec — there is no --method
    flag; the verb refuses to GET.
  * Honors --publisher/--group/--version, --idempotency-key, the
    disable_writes gate, and the result envelope just like 'bcli post'.
… root

Two follow-ups to the bound-action work that came out of design review:

1. ``bcli action`` no longer requires an explicit ``--data`` or
   ``--no-data`` flag. An empty body is the default — matching
   ``bcli post`` semantics — so an AI agent invoking
   ``bcli action examples 42 archive`` for a no-parameter action no
   longer hits a ``BadParameter``. ``--no-data`` is retained as an
   explicit no-op alias; ``--data`` and ``--no-data`` together is
   still an error.

2. Unbound actions at the OData service root (``Namespace.action``
   with no parent entity set) are now routed instead of rejected. The
   resolver composes ``companies(<cid>)/<Namespace>.<action>`` under
   either the standard ``/api/v2.0/`` path or an explicit
   ``--publisher/--group/--version`` custom route. The
   ``disable_standard_api`` security gate still applies when no
   explicit override is supplied — locked-down profiles must opt into
   a specific custom API path, mirroring the protection on standard
   entity sets.

Tests:
- ``test_neither_flag_defaults_to_empty_body`` replaces the previous
  "neither flag is an error" assertion.
- ``TestUnboundActionAtServiceRoot`` covers: standard-route routing,
  custom-route override, ``disable_standard_api`` enforcement, and
  namespace agnosticism.

Full suite: 862 passed, 5 skipped (+3 net from the prior 859 baseline).
OData actions (bound and unbound) have no inverse operation — there is
no DELETE that undoes an archive, supersede, or refreshAll. If a step
records an action POST in the batch ledger and the action returns a body
with an ``id`` field, the previous ``_compose_rollback_url`` logic would
naïvely append ``(id)`` to the action endpoint, producing nonsense like
``examples(42)/Microsoft.NAV.archive(new-id-789)``. Subsequent
``bcli batch rollback`` would then send a DELETE to that path and fail
in a confusing way.

Detect action endpoints (both bound ``entitySet(key)/Ns.action`` and
unbound ``Ns.action``) early and return ``None`` so the ledger records
``rollback_skipped`` — the correct semantics for a non-invertible step.

Regression coverage: ``tests/test_workflow/test_rollback_url_bound_action.py``.
CI ran ``uv sync --locked --extra dev --extra etl`` and refused with
``The lockfile at 'uv.lock' needs to be updated, but '--locked' was
provided``. The 0.4.0 release commit bumped pyproject.toml's project
version but did not regenerate uv.lock, so every Tests run since
2026-05-18 (including this PR's, before any of the bound-action
changes are even installed) errors out before pytest is invoked.

Regenerating with ``uv lock`` yields the same two-character diff —
the editable ``bc-cli`` entry catches up to its declared version.
@igor-ctrl igor-ctrl merged commit e35b315 into main May 22, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant